Skip to content

Fix phpstan/phpstan#14458: Regression: never defined variable#5451

Merged
ondrejmirtes merged 3 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-t7spth4
Apr 13, 2026
Merged

Fix phpstan/phpstan#14458: Regression: never defined variable#5451
ondrejmirtes merged 3 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-t7spth4

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

Summary

After #5386 (commit 52704a4), a variable conditionally defined inside an if (isset(...)) block was incorrectly reported as "never defined" when used with the null coalesce operator ($c ?? null). This happened when the code also contained an array_key_exists() check on the same array parameter after a type-narrowing throw branch.

Changes

  • Added a guard in src/Analyser/MutatingScope.php (Pass 2 of filterBySpecifiedTypes) to skip conditional expressions whose type holder has No certainty during supertype matching
  • Added regression test in tests/PHPStan/Rules/Variables/data/bug-14458.php
  • Added test method testBug14458 in tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php

Root cause

The two-pass conditional expression resolution introduced in #5386 uses a supertype match in Pass 2 as a fallback when exact matching fails. The problem is that the "undefined" branch of a conditional variable (with No certainty) has a broader condition type than the "defined" branch. This makes the supertype check succeed for the "undefined" branch while failing for the "defined" branch, causing the variable to be incorrectly resolved as never defined.

For example, with if (isset($payload['c'])) { $c = ...; }, the conditional expressions are:

  1. Condition: $payload has type array&hasOffsetValue('a', 'b')&hasOffsetValue('c', ...) -> $c is defined (Yes)
  2. Condition: $payload has type array&hasOffsetValue('a', 'b') -> $c is undefined (No)

When a later array_key_exists('cpwv', $payload) narrows $payload's type, Pass 2's supertype check matches condition 2 (broader type is supertype) but not condition 1 (narrower type is not supertype), causing $c to be incorrectly marked as never defined.

The fix skips No-certainty holders in Pass 2, since they represent the "variable is not defined" case which should only be resolved via exact matching (Pass 1). This prevents the asymmetric supertype match from incorrectly resolving conditionally-defined variables as undefined.

Test

The regression test reproduces the exact scenario from the issue: a function with array<mixed> parameter where a throw branch narrows the array type, an isset() check conditionally defines $c, and a subsequent array_key_exists() check triggers the incorrect resolution. The test expects no errors for $c ?? null.

Fixes phpstan/phpstan#14458

…as never defined

- Skip No-certainty conditional expressions in Pass 2 (supertype match) of filterBySpecifiedTypes
- The supertype match could incorrectly resolve only the "undefined" branch of a conditional variable, marking it as never defined
- New regression test in tests/PHPStan/Rules/Variables/data/bug-14458.php

// Pass 2: Supertype match. Only runs when Pass 1 found no exact match for this expression.
foreach ($conditionalExpressions as $conditionalExpression) {
if ($conditionalExpression->getTypeHolder()->getCertainty()->no()) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need a similar check for the "Pass 1" foreach loop a few lines above?
if so, add a test-case which fails without a similar fix for "Pass 1".

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pushed. Here's a summary of the review response:

staabm's question: Does Pass 1 need a similar No-certainty check?

Answer: No. The key difference is the matching strategy:

  • Pass 1 uses exact matching (equals()). If the specified type exactly equals the No branch's condition type, the scope genuinely corresponds to the "variable is undefined" state. Exact matching can't produce the asymmetry that causes the bug — it's impossible for a type to simultaneously equal two different condition types.

  • Pass 2 uses supertype matching (isSuperTypeOf()), which is inherently asymmetric. A broader condition type (the "undefined" branch, with fewer constraints like array&hasOffsetValue('a', 'b')) is more likely to be a supertype of a narrowed type than a narrower condition type (the "defined" branch, like array&hasOffsetValue('a', 'b')&hasOffsetValue('c', ...)). This asymmetry causes only the No-certainty branch to match, incorrectly resolving the variable as never defined.

I added a comment explaining this reasoning directly in the code at the Pass 2 check, so future readers understand why the guard is only needed there. All tests (11,853) and make phpstan pass.

phpstan-bot and others added 2 commits April 13, 2026 12:32
Address review feedback: Pass 1 uses exact matching which cannot produce
the asymmetry that causes the bug in Pass 2. The supertype match in
Pass 2 is inherently asymmetric - a broader condition type (the
"undefined" branch) is more likely to be a supertype of a narrowed type
than a narrower condition type (the "defined" branch). Exact matching
has no such asymmetry.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@staabm staabm requested a review from VincentLanglet April 13, 2026 12:48
@ondrejmirtes ondrejmirtes merged commit 9852694 into phpstan:2.1.x Apr 13, 2026
655 checks passed
@ondrejmirtes ondrejmirtes deleted the create-pull-request/patch-t7spth4 branch April 13, 2026 12:53
@ondrejmirtes
Copy link
Copy Markdown
Member

Thank you.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants